Doobie Introduction
Database is always a thing for developer to consider, there are lots of tools in other languages. In this blog, Let’s introduce a tool for Scala developer: doobie
What is doobie?
doobie is a pure-functional JDBC layer for Scala.
doobie provides low-level access to everything in java.sql (as of Java 8), allowing you to write any JDBC program in a pure functional style.
We can say doobie is just a FP wrapper of JDBC, it just help you translate the FP style code to java.sql
code.
How to install?
Add the following configuration into your build.sbt
scalacOptions += "-Ypartial-unification" // 2.11.9+
libraryDependencies ++= Seq(
// Start with this one
"org.tpolecat" %% "doobie-core" % "0.8.8",
// And add any of these as needed
"org.tpolecat" %% "doobie-h2" % "0.8.8", // H2 driver 1.4.200 + type mappings.
"org.tpolecat" %% "doobie-postgres" % "0.8.8", // Postgres driver 42.2.9 + type mappings.
"org.tpolecat" %% "doobie-hikari" % "0.8.8", // HikariCP transactor.
"org.tpolecat" %% "doobie-quill" % "0.8.8", // Support for Quill 3.4.10
"org.tpolecat" %% "doobie-specs2" % "0.8.8" % "test", // Specs2 support for typechecking statements.
"org.tpolecat" %% "doobie-scalatest" % "0.8.8" % "test" // ScalaTest support for typechecking statements.
)
doobie-core
is the essential dependency.
doobie-h2
and doobie-postgres
are the JDBC driver, please choose them as needed
doobie-hikari
supply another implementation of Transactor
which support to manage the connection pool.
From version 0.7, doobie-quill
can help you generate sql from your model which is more safe and easy to maintain, but you can still compose sql by Fragment
.
doobie-specs2
and doobie-scalatest
are for test, choose them according to your test framework.
Core Concept
In simple terms doobie translate all the models in java.sql
to corresponding Free Monad
, and use these Free Monad
to compose program, then interpret the program to real java.sql
as needed.
ConnectionIO
The Free Monad
of java.sql.Connection
, the program of doobie will become ConnectionIO
finally.
All the ConnectionIO
chained by map
or flatMap
will be run in the same transaction.
type ConnectionIO[A] = Free[ConnectionOp, A]
trait ConnectionOp[A]
final case object Commit extends ConnectionOp[Unit]
final case object CreateStatement extends ConnectionOp[Statement]
final case class PrepareStatement(a: String) extends ConnectionOp[PreparedStatement]
.....
Fragment
In Java, we use the lieral string to define sql statement and use ?
as the placeholder of parameter which can be supplied laterly. for example
PreparedStatement updateAge = null;
String updateString = "update person set age = ? where name = ?";
updateAge = con.prepareStatement(updateString);
updateAge.setInt(1, 18);
updateAge.setString(2, "Tom");
updateAge.executeUpdate();
To get a completed PreparedStatement
, we need to prepare 3 things
- The sql template
- The parameter posiontion
- The parameter type
We don’t know if the sql is correct before runing it in Java.
To make it easier, doobie defined Fragment
which can apply type level checking when we prepare the sql and remember the parameter position and type. for example
val age: Int =18
val name: String = "Tom"
val sql = sql"update person set age = $age where name = $name"
sql.update.run.unsafeRunSync
Here the sql
interpolator will help us construct Fragment
.
Query and Update
In Java, we run query or update by invoking different methods of PreparedStatement
, for example
PreparedStatement selectPerson = connection.prepareStatement("select name from person");
ResultSet rs = selectPerson.executeQuery()
while (rs.next()) {
String name = rs.getString("name");
System.out.println("name: " + name);
}
PreparedStatement updateAge = connection.prepareStatement("update person set age = 18");
selectPerson.executeUpdate()
We can see the process of query and update are different, so doobie define two differnt models for them: Query
and Update
Query
In Query
we can control the expected type of query result, which can even let us apply more flexible checking on the number of result.
-
Query.unique
Return exactly one record, will raise error when there is no or more than 1 records in
ResultSet
def getUnique[A: Read]: ResultSetIO[A] = (getNext[A], next).tupled.flatMap { case (Some(a), false) => FRS.delay(a) case (Some(_), true) => FRS.raiseError(UnexpectedContinuation) case (None, _) => FRS.raiseError(UnexpectedEnd) }
-
Query.option
Return 0(None) or 1(Some) record, will raise error when there are more than 1 records in
ResultSet
def getOption[A: Read]: ResultSetIO[Option[A]] = (getNext[A], next).tupled.flatMap { case (a @ Some(_), false) => FRS.delay(a) case (Some(_), true) => FRS.raiseError(UnexpectedContinuation) case (None, _) => FRS.delay(None) }
-
Query.to[F[_]]
Put all the records in
ResultSet
intoF[_]
, such asList
-
Query.nel
Put all the records in
ResultSet
intoNonEmptyList
, will raise error when there is no record.def nel[A: Read]: ResultSetIO[NonEmptyList[A]] = (getNext[A], list).tupled.flatMap { case (Some(a), as) => FRS.delay(NonEmptyList(a, as)) case (None, _) => FRS.raiseError(UnexpectedEnd) }
-
Query.stream
Put all the records in
ResultSet
intofs2.Stream
Update
Update
is simpler than Query
, we just need to invoke Update.run
to execute the update
Data Mapping
You may be already aware of this part, no matter what we do by Query
and Update
, we always need to consider
- How to convert the Scala data type to Database data type
- How to convert the Database data type to Scala data type
And for high level program, we also need to consider
- How to convert the Scala data model to Database record
- How to convert the Database record to Scala data model
doobie define 4 type class to help us do this work
Get[A]
- convert Database data type to Scala data typePut[A]
- convert Scala data type to Database data typeRead[A]
- convert Database record to Scala data modelWrite[A]
- convert Scala data model to Database record
Usually we don’t need to care about them, doobie already defined them for most of the data type, even for case class
. we can query case class
directly like this
case class Person(name:String, age:Int)
sql"select name, age from person".query[Person].to[List]
If the existing data mapping can not meet our requirements, we can always generate new data mapping from existing one.
Transactor
A Transactor
is just the interpretor of Free Monad
, which will translate the program to java.sql
program.
We can set up the JDBC driver, connection pool in Transactor
.
Usage
How to connect to a database?
To connect to a database, we need to add the corresponding database dependency in our configuration.
And pass the JDBC driver name to Transactor
.
implicit val cs:ContextShift = ???
val xa = Transactor.fromDriverManager[IO](
"org.postgresql.Driver", // driver classname
"jdbc:postgresql:world", // connect URL (driver-specific)
"postgres", // user
"" // password
)
How to run sql?
- Use
sql
interpolator to construct aFragment
- Generate a
Query
byFragment.query
orUpdate
byFragment.update
- Generate
ConnectionIO
byQuery.to
,Query.unique
,Query.option
orUpdate.run
- Pass
ConnectionIO
toTransactor
to geneateIO
- Run the
IO
val program: ConnectionIO[Int] = sql"select 42".query[Int].unique
val io:IO[Int] = program2.transact(xa)
io.unsafeRunSync
How to run a query?
-
Single Column
sql"select name from person" .query[String] // Query0[String] .to[List] // ConnectionIO[List[String]] .transact(xa) // IO[List[String]] .unsafeRunSync // List[String]
-
Multiple Column
sql"select name, age from person" .query[(String, Int)] //Query0[(String, Int)] .to[List] //ConnectionIO[List[(String, Int)]] .transact(xa) //IO[List[(String, Int)]] .unsafeRunSync //List[(String, Int)]
-
Custom Model
case class Person(name:String, age:Int) sql"select name, age from person" .query[Person] //Query0[Person] .to[List] //ConnectionIO[List[Person]] .transact(xa) //IO[List[Person]] .unsafeRunSync //List[Person]
-
With Condition
case class Person(name:String, age:Int) val ageThreshold:Int = 18 sql"select name, age from person where age > ${ageThreshold}" .query[Person] //Query0[Person] .to[List] //ConnectionIO[List[Person]] .transact(xa) //IO[List[Person]] .unsafeRunSync //List[Person]
case class Person(name:String, age:Int) val requiredName:NonEmptyList[String] = NonEmptyList.of("Tom", "Jerry", "John") sql"select name, age from person where " ++ Fragments.in(fr"name", requriedName) .query[Person] //Query0[Person] .to[List] //ConnectionIO[List[Person]] .transact(xa) //IO[List[Person]] .unsafeRunSync //List[Person]
How to insert a record?
val person:Person = ???
sql"insert into person (name, age) values ($person.name, $person.age)"
.update //Update0
.run //ConnectionIO[Int]
.transact(xa) //IO[Int]
.unsafeRunSync //Int
How to update a record?
sql"update person set age = 18 where name = 'Tom'"
.update //Update0
.run //ConnectionIO[Int]
.transact(xa) //IO[Int]
.unsafeRunSync //Int
How to delete a record?
sql"delete from person where name = 'Tom'"
.update //Update0
.run //ConnectionIO[Int]
.transact(xa) //IO[Int]
.unsafeRunSync //Int
How to create new type mapping between Database and Scala?
Say we defined a Status
to check if a person is free
sealed trait Status
case object Busy extends Status
case object Free extends Status
We know there is no corresponding data type in Database. but we still want to compose program like this
val status:Status = Free
sql"update person set status = $status where name = 'Tom'"
.update
.run
.transact(xa)
.unsafeRunSync
sql"select status from person where name 'Tome'"
.query[Status]
.unique
.transact(xa)
.unsafeRunSync
So we need to define the instance of Get[Status]
and Put[Status]
to tell doobie how to mapping Status
object Status {
implicit statusGet:Get[Status] = Get[String].map[Status](x => if(x == "busy") Busy else Free)
implicit statusPut:Put[Status] = Put[String].contramap[Status](x => if(x == Busy) "busy" else "free")
}
How to manage the connections?
doobie-hikari
library supply another implementation of Transactor
which support connection pool
val transactor: Resource[IO, HikariTransactor[IO]] =
for {
ce <- ExecutionContexts.fixedThreadPool[IO](32) // our connect EC
be <- Blocker[IO] // our blocking EC
xa <- HikariTransactor.newHikariTransactor[IO](
"org.h2.Driver", // driver classname
"jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", // connect URL
"sa", // username
"", // password
ce, // await connection here
be // execute JDBC operations here
)
} yield xa
How to do test?
doobile supply test library both for specs2
and scalatest
, but it need to connect the database.
-
Specs2
class AnalysisTestSpec extends Specification with doobie.specs2.IOChecker { val transactor = Transactor.fromDriverManager[IO]( "org.postgresql.Driver", "jdbc:postgresql:world", "postgres", "" ) check(sql"select * from person".query[Person]) }
-
ScalaTest
class AnalysisTestScalaCheck extends FunSuite with Matchers with doobie.scalatest.IOChecker { val transactor = Transactor.fromDriverManager[IO]( "org.postgresql.Driver", "jdbc:postgresql:world", "postgres", "" ) test("query") { check(sql"select * from person".query[Person])} }
Summary
Hope this document can help you, doobie has a very good official document, you can find more advanced usage there.
If you are interested in the source code of doobie, please pay attention to the Embedded
model, it use a special solution to compose program with multile Free Monad
.
Comments